5.05. Итераторы и yield
Итераторы и yield
Итератор представляет собой механизм, позволяющий последовательно перебирать элементы коллекции или последовательности без необходимости загружать всю структуру данных в память целиком. Он обеспечивает ленивую (отложенную) обработку: каждый элемент вычисляется по мере необходимости, а не заранее. Такой подход особенно полезен при работе с большими или потенциально бесконечными наборами данных, где хранение всех значений одновременно привело бы к избыточному потреблению ресурсов.
В языках программирования, поддерживающих концепцию итераторов, разработчик может определить собственное поведение перебора. Это достигается через реализацию специального интерфейса или использование синтаксических конструкций, упрощающих создание итераторов. В C# такая задача решается с помощью ключевого слова yield.
Ключевое слово yield
Ключевое слово yield предоставляет компилятору инструкцию автоматически сгенерировать итератор на основе метода, в котором оно используется. Метод, содержащий yield, называется итераторным методом. Он не возвращает готовую коллекцию, а описывает логику получения каждого следующего элемента. При каждом вызове итератора выполнение метода возобновляется с того места, где оно было приостановлено ранее, благодаря внутреннему состоянию, сохраняемому компилятором.
Существует две основные формы использования yield:
yield return— возвращает очередной элемент последовательности и приостанавливает выполнение метода до следующего запроса.yield break— завершает итерацию, указывая, что больше элементов нет.
Эти конструкции позволяют писать код, который читается как последовательный, но выполняется как состояние машины с возможностью приостановки и возобновления.
Принцип работы yield return
Когда метод содержит yield return, компилятор трансформирует его в класс, реализующий интерфейс IEnumerable<T> или IEnumerator<T>. Этот класс сохраняет текущее состояние выполнения: значения локальных переменных, позицию в коде, контекст цикла. При первом обращении к итератору создаётся экземпляр этого сгенерированного класса. Каждый вызов метода MoveNext() (явный или неявный, например, через foreach) приводит к продолжению выполнения метода с точки остановки до следующего yield return или yield break.
Таким образом, yield return действует как временная точка выхода из метода, после которой управление возвращается вызывающему коду, но внутреннее состояние сохраняется для будущего использования. Это позволяет эффективно моделировать потоки данных, генераторы и другие сценарии, где важна пошаговая выдача информации.
Пример простого итератора:
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
При вызове GetNumbers() не создаётся массив [1, 2, 3]. Вместо этого возвращается объект, который при первом запросе вернёт 1, при втором — 2, при третьем — 3, а затем завершит итерацию. Память выделяется только под текущее значение и состояние итератора.
Использование yield break
Конструкция yield break завершает итерацию досрочно. Она аналогична оператору return в обычном методе, но применяется в контексте итератора. После yield break метод прекращает выполнение, и последующие вызовы MoveNext() будут возвращать false, сигнализируя об окончании последовательности.
Пример с условным завершением:
public static IEnumerable<int> GetLimitedNumbers(int limit)
{
for (int i = 1; i <= 10; i++)
{
if (i > limit)
yield break;
yield return i;
}
}
Если вызвать GetLimitedNumbers(5), итератор вернёт числа от 1 до 5, после чего остановится. Без yield break цикл продолжил бы выполнение, но yield return не был бы достигнут, что привело бы к тому же результату, однако явное использование yield break делает намерение разработчика более очевидным и улучшает читаемость.
Создание пользовательских итераторов
Пользовательские итераторы позволяют определять собственные правила перебора. Это особенно полезно при работе с древовидными структурами, графами, файловыми системами, потоками событий или любыми данными, где стандартный перебор недостаточен.
Рассмотрим пример обхода дерева в глубину:
public class TreeNode
{
public int Value { get; set; }
public List<TreeNode> Children { get; } = new();
public IEnumerable<int> TraverseDepthFirst()
{
yield return Value;
foreach (var child in Children)
{
foreach (var value in child.TraverseDepthFirst())
{
yield return value;
}
}
}
}
Здесь метод TraverseDepthFirst рекурсивно возвращает значения узлов в порядке обхода в глубину. Благодаря yield return, каждый элемент выдаётся по мере достижения, без создания промежуточного списка. Это экономит память и позволяет начать обработку данных сразу, не дожидаясь завершения всего обхода.
Альтернативный подход — использование стека в итеративной реализации — также совместим с yield, но рекурсивный стиль часто оказывается более естественным для таких задач.
Преимущества итераторов с yield
- Экономия памяти — данные генерируются по требованию, а не хранятся целиком.
- Линейная читаемость — логика перебора выражается в виде последовательного кода, а не через сложные структуры состояний.
- Отложенное выполнение — вычисления происходят только тогда, когда это необходимо, что позволяет избежать ненужной работы.
- Поддержка бесконечных последовательностей — можно создавать итераторы, которые теоретически никогда не заканчиваются, например, генератор случайных чисел или поток событий.
Пример бесконечной последовательности:
public static IEnumerable<long> Fibonacci()
{
long a = 0, b = 1;
while (true)
{
yield return a;
long temp = a + b;
a = b;
b = temp;
}
}
Такой итератор можно использовать в связке с LINQ-методами, например, Take(10), чтобы получить первые десять чисел Фибоначчи, не заботясь о том, как именно они генерируются.
Ограничения и особенности
Метод с yield не может содержать параметры ref или out. Он не может быть асинхронным (async) в традиционном смысле (хотя в современных версиях C# появились IAsyncEnumerable<T> и yield return в асинхронных итераторах, но это отдельная тема). Локальные переменные внутри итераторного метода сохраняются между вызовами, что может привести к неожиданному поведению, если их состояние зависит от внешних факторов.
Кроме того, исключения, возникающие внутри итератора, выбрасываются не при вызове метода, а при первом обращении к итератору (например, при входе в цикл foreach). Это важно учитывать при отладке и обработке ошибок.
Связь с LINQ и функциональным стилем
Итераторы на основе yield органично вписываются в функциональный подход к обработке данных. Они позволяют строить цепочки преобразований, где каждый шаг лениво вычисляется. Например, можно создать итератор, фильтрующий, преобразующий и ограничивающий последовательность, не создавая промежуточных коллекций:
public static IEnumerable<string> GetEvenNumberLabels(int max)
{
for (int i = 1; i <= max; i++)
{
if (i % 2 == 0)
yield return $"Число {i}";
}
}
Этот код легко комбинируется с другими методами, такими как Where, Select, Skip, Take, поскольку он возвращает IEnumerable<T>, совместимый с LINQ.
Сравнение с ручной реализацией IEnumerator<T>
До появления yield разработчикам приходилось вручную реализовывать интерфейсы IEnumerable<T> и IEnumerator<T>, что требовало написания значительного объёма шаблонного кода. Такой подход включал создание отдельного класса-итератора, хранящего текущее состояние (например, индекс в массиве или узел в дереве), а также логику перехода к следующему элементу и проверки завершения.
Пример ручной реализации:
public class NumberSequence : IEnumerable<int>
{
private readonly int _count;
public NumberSequence(int count)
{
_count = count;
}
public IEnumerator<int> GetEnumerator()
{
return new NumberEnumerator(_count);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class NumberEnumerator : IEnumerator<int>
{
private readonly int _count;
private int _current = -1;
public NumberEnumerator(int count)
{
_count = count;
}
public int Current { get; private set; }
object IEnumerator.Current => Current;
public bool MoveNext()
{
_current++;
if (_current >= _count)
return false;
Current = _current + 1;
return true;
}
public void Reset() => _current = -1;
public void Dispose() { }
}
Этот код работает корректно, но он громоздкий, подвержен ошибкам и труден для сопровождения. Любое изменение логики перебора требует модификации нескольких методов и переменных состояния. В отличие от этого, итератор с yield return выражает ту же логику в три строки, оставляя компилятору задачу генерации всего необходимого шаблонного кода.
Таким образом, yield не заменяет IEnumerator<T>, а предоставляет декларативный способ его создания. Под капотом результат идентичен: создаётся скрытый класс, реализующий нужные интерфейсы, с полями для хранения состояния и методами для управления итерацией.
Производительность и накладные расходы
Использование yield не бесплатно. Компилятор генерирует дополнительный класс, который создаётся при каждом вызове итераторного метода. Этот объект занимает память и может быть подвержен сборке мусора, особенно если итерация прерывается досрочно. Однако в большинстве случаев эти накладные расходы незначительны по сравнению с выгодами от ленивой оценки и упрощения кода.
Важно понимать, что каждый вызов итераторного метода создаёт новый экземпляр итератора. Это означает, что повторное использование одного и того же вызова в нескольких циклах приведёт к многократному выполнению логики метода:
var numbers = GetNumbers(); // возвращает IEnumerable<int>
foreach (var n in numbers) { /* ... */ } // первый проход
foreach (var n in numbers) { /* ... */ } // второй проход — метод вызывается заново
Если логика внутри итератора дорогостоящая (например, чтение из файла или сетевой запрос), такой подход может привести к неэффективности. В таких случаях рекомендуется материализовать последовательность с помощью .ToList() или .ToArray() после первого прохода.
Практические рекомендации
-
Используйте
yieldдля ленивых последовательностей
Когда данные могут быть велики, бесконечны или вычисляются дорого,yieldпозволяет начать обработку немедленно, без предварительного формирования всей коллекции. -
Избегайте побочных эффектов в итераторах
Поскольку итератор может быть вызван несколько раз или не до конца, любые побочные эффекты (например, запись в лог, изменение глобального состояния) должны быть идемпотентными или явно документированы. -
Не используйте
yieldв методах с параметрамиrefилиout
Это ограничение языка C#. Если требуется передача по ссылке, реализуйте итератор вручную или реорганизуйте логику. -
Будьте осторожны с захватом переменных в циклах
При использовании замыканий внутри итератора важно понимать, что переменные цикла захватываются по ссылке. Это может привести к неожиданному поведению, если итератор выполняется асинхронно или отложен. -
Предпочитайте
yield breakявномуreturn
Хотяreturnв итераторном методе автоматически преобразуется вyield break, явное использование последнего делает код более читаемым и сигнализирует о намерении завершить итерацию. -
Тестируйте итераторы как потоки данных
Проверяйте поведение при пустых входных данных, досрочном завершении (breakвforeach) и многократном переборе. Убедитесь, что исключения выбрасываются в правильный момент.
Асинхронные итераторы (IAsyncEnumerable)
Начиная с C# 8.0, появилась поддержка асинхронных итераторов через интерфейс IAsyncEnumerable<T>. Они позволяют использовать yield return внутри методов, помеченных как async, и возвращать элементы по мере их асинхронного получения — например, из базы данных, сети или файловой системы.
Пример:
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(File.OpenRead(path));
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
yield return line;
}
}
Такой итератор можно использовать с await foreach, обеспечивая эффективную обработку потоковых данных без блокировки потока выполнения.
Асинхронные итераторы расширяют философию yield на асинхронный мир, сохраняя те же принципы: ленивость, экономию памяти и декларативность.
Основные интерфейсы, лежащие в основе итераторов
IEnumerable<T>
Интерфейс, представляющий коллекцию, по которой можно выполнить перебор. Он содержит единственный метод:
GetEnumerator()— возвращает объект, реализующийIEnumerator<T>.
Этот метод вызывается неявно при использовании конструкцииforeach.
Любой метод, использующий yield return, автоматически возвращает тип, совместимый с IEnumerable<T> (или IEnumerable, если не указан обобщённый параметр).
IEnumerator<T>
Интерфейс, управляющий процессом перебора отдельного элемента за раз. Он предоставляет следующие члены:
Current— свойство типаT, возвращающее текущий элемент последовательности.MoveNext()— метод, перемещающий итератор к следующему элементу. Возвращаетtrue, если следующий элемент существует, иfalse, если перебор завершён.Reset()— метод, сбрасывающий итератор в начальное состояние. На практике редко используется; многие реализации выбрасываютNotSupportedException.Dispose()— метод интерфейсаIDisposable, вызываемый для освобождения ресурсов после завершения перебора (например, при выходе из блокаusingилиforeach).
Когда компилятор обрабатывает метод с yield, он генерирует скрытый класс, реализующий IEnumerator<T> и IDisposable.
Связанные необобщённые интерфейсы
Для совместимости с устаревшим кодом существуют также необобщённые версии:
IEnumerable— содержитGetEnumerator(), возвращающийIEnumerator.IEnumerator— содержитCurrentтипаobject,MoveNext()иReset().
Современный код должен использовать обобщённые версии (IEnumerable<T>, IEnumerator<T>), так как они типобезопасны и избегают упаковки/распаковки значимых типов.
Классы, автоматически генерируемые компилятором
При наличии yield return в методе компилятор создаёт скрытый вложенный класс (обычно с именем вроде <>d__N, где N — номер), который:
- Реализует
IEnumerable<T>и/илиIEnumerator<T>(в зависимости от сигнатуры метода). - Содержит поля для хранения всех локальных переменных, параметров метода и состояния машины (например, метка перехода).
- Содержит метод
MoveNext(), в котором размещается весь исходный код метода, преобразованный в конечный автомат. - Реализует
IDisposable.Dispose(), чтобы корректно завершить итерацию при досрочном выходе.
Этот класс не виден в исходном коде, но доступен через инструменты вроде IL Spy или dotPeek.
Методы, используемые с итераторами
Хотя yield сам по себе не требует вызова специальных методов, он тесно интегрирован с экосистемой LINQ и стандартными конструкциями языка:
В языке C#
foreach— неявно вызываетGetEnumerator(), затем многократно вызываетMoveNext()и читаетCurrent.using— гарантирует вызовDispose()после завершения перебора, даже при исключении.
В LINQ (System.Linq)
Методы расширения, работающие с IEnumerable<T>, могут принимать результат итератора напрямую:
Where,Select,Take,Skip,First,ToList,ToArrayи другие.
Пример:
var squares = GenerateNumbers().Select(x => x * x).Take(5);
Здесь GenerateNumbers() — итераторный метод с yield return. Вся цепочка выполняется лениво, пока не вызван ToList() или аналог.
Свойства поведения итераторов с yield
-
Ленивая оценка
Элементы вычисляются только при запросе черезMoveNext(). -
Сохранение состояния
Все локальные переменные сохраняют свои значения между вызовамиMoveNext(). -
Однократное выполнение логики инициализации
Код до первогоyield returnвыполняется при первом вызовеMoveNext(). -
Поддержка вложенных итераторов
Можно использоватьyield returnвнутри цикла, который сам перебирает другойIEnumerable<T>(часто сforeach). -
Автоматическая реализация
IDisposable
Если в итераторе есть блокиtry-finallyили используются ресурсы, компилятор гарантирует их освобождение при завершении итерации.
Асинхронные аналоги (начиная с C# 8.0)
IAsyncEnumerable<T>
Асинхронный аналог IEnumerable<T>. Поддерживает отложенный асинхронный перебор.
GetAsyncEnumerator(CancellationToken)— возвращаетIAsyncEnumerator<T>.
IAsyncEnumerator<T>
Асинхронный аналог IEnumerator<T>.
Current— свойство типаT.MoveNextAsync()— асинхронный метод, возвращающийValueTask<bool>.
Ключевые слова в асинхронных итераторах
yield return— допустим внутриasync IAsyncEnumerable<T>метода.yield break— завершает асинхронную итерацию.
Использование
await foreach (var item in GetItemsAsync())
{
// обработка
}
Типичные сигнатуры методов с yield
public IEnumerable<T> MethodName(/* параметры */)
{
// yield return ...
// yield break ...
}
public IEnumerator<T> GetEnumerator()
{
// yield return ...
}
Важно: метод не может одновременно:
- быть
asyncи возвращатьIEnumerable<T>(для асинхронности требуетсяIAsyncEnumerable<T>), - содержать
ref/outпараметры, - быть конструктором, деструктором или оператором.